Skip to content

Validate the optimistic lock in INSERT ON CONFLICT DO UPDATE#6306

Open
eyupcanakman wants to merge 1 commit into
lysine-dev:masterfrom
eyupcanakman:fix/optimistic-lock-upsert
Open

Validate the optimistic lock in INSERT ON CONFLICT DO UPDATE#6306
eyupcanakman wants to merge 1 commit into
lysine-dev:masterfrom
eyupcanakman:fix/optimistic-lock-upsert

Conversation

@eyupcanakman

@eyupcanakman eyupcanakman commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Refs #6232

OptimisticLockValidator only pattern-matches SqlUpdateStmt and SqlUpdateStmtLimited, so an INSERT ... ON CONFLICT DO UPDATE never reached the lock check.
The DO UPDATE SET clause below drops the version column declared AS LOCK, and it compiles:

CREATE TABLE Thing (
  id INTEGER PRIMARY KEY NOT NULL,
  external_id INTEGER NOT NULL,
  stuff TEXT NOT NULL,
  version INTEGER AS LOCK NOT NULL DEFAULT 0,

  UNIQUE (external_id)
);

upsertThing:
INSERT INTO Thing (external_id, stuff)
VALUES (?1, ?2)
ON CONFLICT (external_id) DO UPDATE
SET stuff = ?2;

The checks the UPDATE path applies now run against DO UPDATE SET too, in the sqlite-3-24 and sqlite-3-35 dialects.
Postgres and MySQL upserts have no WHERE in their DO UPDATE grammar, so the same check is a separate question there and stays out of this PR, which is why this is Refs and not Closes.

An UPDATE cannot express excluded, which holds the row being inserted rather than the stored one.
SET version = excluded.version + 1 would pass as a self increment if you only compare column names, so both checks now read the lock from the target table or its alias.

Two things I would like your read on, @griffio.

I kept the self increment from #6240, so SET version = version + 1 skips the WHERE check here too.
An upsert's DO UPDATE only touches the row that conflicted, so the multirow reasoning does not carry over.
Should an upsert require the bind form?

QueryGenerator wires the lock only for NamedMutator.Update, so a stale locked upsert affects zero rows without throwing OptimisticLockException.
I can pick that up here or in a follow-up.

Docs are untouched. The optimistic locking section is only included on the H2, MySQL and Postgres pages, never the SQLite ones.


  • CHANGELOG.md's "Unreleased" section has been updated, if applicable.

@griffio

griffio commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Should an upsert require the bind form?

For the runtime validation to return the rows updated it would have a where clause for version lock
https://sqlite.org/lang_upsert.html#examples mentions use-case of where clause
If the WHERE version = ? clause is false, the DO UPDATE is skipped entirely and the whole statement is a silent no-op.

QueryGenerator wires the lock only for NamedMutator.Update, so a stale locked upsert affects zero rows without throwing OptimisticLockException

Yes, I think so because it's related to the runtime validation and checking the row affected

Current issues I can see:

Duplication of validateOptimisticLock in two places and original app/cash/sqldelight/dialects/sqlite_3_24/grammar/mixins/InsertStmtMixin.kt
app/cash/sqldelight/dialects/sqlite_3_35/grammar/mixins/InsertStmtMixin.kt
original app.cash.sqldelight.core.lang.validation.OptimisticLockValidator

So, there needs to be a way to keep the validation in a single place.
Maybe not in this PR, but PostgreSql dialect would have to share same validation as well.

The docs can be updated in another PR.

@eyupcanakman
eyupcanakman force-pushed the fix/optimistic-lock-upsert branch from ad7ab33 to eeac5ba Compare July 17, 2026 00:03
@eyupcanakman

Copy link
Copy Markdown
Contributor Author

Both setter forms still work, but the lock now has to be checked in a WHERE clause either way, self increment included. The runtime throw wants your single validation first I think, since QueryGenerator only sees core sql-psi and there are no upsert types in it, so I'd do the shared check and the throw together in a follow-up, if that split works for you.

@griffio

griffio commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Both setter forms still work, but the lock now has to be checked in a WHERE clause either way, self increment included. The runtime throw wants your single validation first I think, since QueryGenerator only sees core sql-psi and there are no upsert types in it, so I'd do the shared check and the throw together in a follow-up, if that split works for you.

Thanks - do it as a stacked PR.
There needs to be a test for sqlite 3_35 multiple optimistic locks.
Even though only one will fire, both must have version checks.

CREATE TABLE test (
    id INTEGER AS VALUE NOT NULL,
    other INTEGER NOT NULL,
    version INTEGER AS LOCK NOT NULL DEFAULT 0,
    text TEXT NOT NULL,
    UNIQUE (id),
    UNIQUE (other)
);
      
upsertText:
INSERT INTO test (id, other, text)
VALUES (:id, :other, :text)
ON CONFLICT (id) DO UPDATE
SET text = :text, version = version + 1
WHERE version = :version
ON CONFLICT (other) DO UPDATE
SET text = :text, version = version + 1
WHERE version = :version;

e.g Result "if (result.value == 0L) throw app.cash.sqldelight.db.OptimisticLockException"

The optimistic lock check only ran for UPDATE statements, so an INSERT with ON CONFLICT DO UPDATE skipped it.
A table with an AS LOCK column would compile an upsert whose DO UPDATE SET never touches the lock.
The same check now runs on the DO UPDATE SET clause in the sqlite-3-24 and sqlite-3-35 dialects.

DO UPDATE also has to carry the WHERE clause a multirow UPDATE can leave out.
Self incrementing the lock bumps every row an UPDATE matches, but DO UPDATE only ever touches the row that conflicted, so without WHERE version = :version the stored lock is never read.

Upserts need one more guard, because excluded is a real query source there.
SET version = excluded.version + 1 and WHERE excluded.version = :version both read back the caller's own incoming value, so neither counts as the stored lock.
The table name and the table alias do.

Refs sqldelight#6232
@eyupcanakman
eyupcanakman force-pushed the fix/optimistic-lock-upsert branch from eeac5ba to 277ee5b Compare July 23, 2026 18:01
@eyupcanakman

Copy link
Copy Markdown
Contributor Author

Added the sqlite 3_35 test with two conflict clauses, both carrying the version check, and it validates clean. Left the runtime throw for the stacked follow-up.

@griffio

griffio commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Added the sqlite 3_35 test with two conflict clauses, both carrying the version check, and it validates clean. Left the runtime throw for the stacked follow-up.

Thanks, the two issues for the follow up are:

  1. Add Runtime throw to detect stale upserts
  2. Remove need for duplicated code as PostgreSql and any other dialects will need the same code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants